CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/next/pages/news/edit/[id].tsx
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2023 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import {
7
Alert,
8
Breadcrumb,
9
Button,
10
Checkbox,
11
Col,
12
DatePicker,
13
Divider,
14
Form,
15
Input,
16
Layout,
17
Row,
18
Select,
19
Space,
20
} from "antd";
21
import dayjs from "dayjs";
22
import { GetServerSidePropsContext } from "next";
23
import { useRouter } from "next/router";
24
import { useEffect, useState } from "react";
25
26
import { getNewsItem } from "@cocalc/database/postgres/news";
27
import { Icon } from "@cocalc/frontend/components/icon";
28
import { capitalize } from "@cocalc/util/misc";
29
import { slugURL } from "@cocalc/util/news";
30
import {
31
CHANNELS,
32
CHANNELS_DESCRIPTIONS,
33
Channel,
34
NewsItem,
35
} from "@cocalc/util/types/news";
36
import Footer from "components/landing/footer";
37
import Head from "components/landing/head";
38
import Header from "components/landing/header";
39
import { Paragraph, Title } from "components/misc";
40
import A from "components/misc/A";
41
import { News } from "components/news/news";
42
import Loading from "components/share/loading";
43
import apiPost from "lib/api/post";
44
import { MAX_WIDTH, NOT_FOUND } from "lib/config";
45
import { Customize, CustomizeType } from "lib/customize";
46
import useProfile from "lib/hooks/profile";
47
import { extractID } from "lib/news";
48
import withCustomize from "lib/with-customize";
49
50
interface Props {
51
customize: CustomizeType;
52
news?: NewsItem;
53
}
54
55
type NewsTypeForm = Omit<NewsItem, "date"> & { date: dayjs.Dayjs };
56
57
export default function EditNews(props: Props) {
58
const { customize, news } = props;
59
const router = useRouter();
60
61
const id = news?.id; // this is set once, and never changes
62
const isNew = id == null;
63
const { siteName } = customize;
64
const profile = useProfile({ noCache: true });
65
const isAdmin = profile?.is_admin === true;
66
67
const [form] = Form.useForm();
68
69
const date: dayjs.Dayjs =
70
typeof news?.date === "number" ? dayjs.unix(news.date) : dayjs();
71
72
const init: NewsTypeForm =
73
news != null
74
? { ...news, tags: news.tags ?? [], date }
75
: {
76
hide: false,
77
title: "",
78
text: "",
79
url: "",
80
tags: [],
81
channel: "feature",
82
date: dayjs(),
83
};
84
85
const [data, setData] = useState<NewsTypeForm>(init);
86
87
const [error, setError] = useState<string>("");
88
const [saving, setSaving] = useState<boolean>(false);
89
const [invalid, setInvalid] = useState<boolean>(false);
90
const [saved, setSaved] = useState<number | null>(null);
91
92
useEffect(() => {
93
form.setFieldsValue(data);
94
95
// If we're creating a new item, set the channel from URL params (if such a param exists).
96
// This is used when creating a new event from the events page.
97
//
98
if (isNew) {
99
const { channel } = router.query;
100
if (
101
typeof channel === "string" &&
102
CHANNELS.includes(channel as Channel)
103
) {
104
form.setFieldValue("channel", channel);
105
}
106
}
107
108
form.validateFields();
109
}, [data]);
110
111
async function save() {
112
setSaving(true);
113
try {
114
// send data, but convert date field to epoch seconds
115
const next = { ...data, id, date: data.date.unix() };
116
const { channel } = data;
117
const ret = await apiPost("/news/edit", next);
118
if (ret == null || ret.id == null) {
119
throw Error("Problem saving news item – no id returned.");
120
}
121
if (channel === "event") {
122
router.push("/about/events", undefined, { scroll: false });
123
} else {
124
router.push(
125
slugURL({
126
...data,
127
...ret,
128
}),
129
undefined,
130
{ scroll: false },
131
);
132
}
133
// this signals to the user that the save was successful
134
setSaved(ret.id);
135
} catch (err) {
136
setError(err.message);
137
} finally {
138
setSaving(false);
139
setError("");
140
}
141
}
142
143
function renderSaved() {
144
if (saving || saved == null) return;
145
return (
146
<Alert
147
banner
148
type="success"
149
icon={<Icon name="check" />}
150
message={
151
<>
152
<A href={slugURL({ ...data, id })}>Saved News id={saved}</A>.
153
</>
154
}
155
/>
156
);
157
}
158
159
function explainChannel(channel: Channel): JSX.Element | string {
160
switch (channel) {
161
case "feature":
162
return "Updates, modified features, general news, etc. The default category for all news.";
163
case "announcement":
164
return "Use this rarely, only once or twice a month.";
165
case "about":
166
return "This is the meta-level category.";
167
case "event":
168
return (
169
"Let users know about upcoming company/conference events. These events are ONLY" +
170
" shown in the About page and are filtered from normal news views."
171
);
172
default:
173
return CHANNELS_DESCRIPTIONS[channel];
174
}
175
}
176
177
function updateChannelParam(channel: string) {
178
const { query } = router;
179
180
router.replace(
181
{
182
query: {
183
...query,
184
channel,
185
},
186
},
187
undefined,
188
{ shallow: true, scroll: false },
189
);
190
}
191
192
function edit() {
193
return (
194
<>
195
<Title level={2}>
196
{isNew ? "Create New News" : `Edit News #${id}`}
197
</Title>
198
<Form
199
form={form}
200
initialValues={data}
201
labelCol={{ span: 4 }}
202
wrapperCol={{ span: 20 }}
203
onValuesChange={(_, allValues) => {
204
setSaved(null);
205
setData(allValues);
206
}}
207
onFieldsChange={() =>
208
setInvalid(form.getFieldsError().some((e) => e.errors.length > 0))
209
}
210
>
211
<Form.Item
212
label="Title"
213
name="title"
214
rules={[{ required: true, min: 1 }]}
215
>
216
<Input />
217
</Form.Item>
218
<Form.Item
219
label="Date"
220
name="date"
221
rules={[{ required: true }]}
222
extra={`Future dates will not be shown until it is time. This date is in the ${
223
form.getFieldValue("date")?.isAfter(dayjs()) ? "future" : "past"
224
}.`}
225
>
226
<DatePicker changeOnBlur showTime={true} allowClear={false} />
227
</Form.Item>
228
<Form.Item
229
label="Channel"
230
name="channel"
231
rules={[{ required: true }]}
232
extra={explainChannel(data.channel)}
233
>
234
<Select onSelect={(value) => updateChannelParam(value)}>
235
{CHANNELS.map((ch) => {
236
return (
237
<Select.Option value={ch} key={ch}>
238
{capitalize(ch)} ({CHANNELS_DESCRIPTIONS[ch]})
239
</Select.Option>
240
);
241
})}
242
</Select>
243
</Form.Item>
244
<Form.Item
245
label="Tags"
246
name="tags"
247
rules={[{ required: false }]}
248
extra={`Common ones are "jupyter", "latex" or "sagemath". Don't set too many, one is usually good enough.`}
249
>
250
<Select mode="tags" style={{ width: "100%" }} />
251
</Form.Item>
252
<Form.Item
253
label="Message"
254
name="text"
255
extra={`Markdown is supported. Insert images via ![](url), e.g. shared on ${siteName} itself.`}
256
rules={[{ required: true, min: 1 }]}
257
>
258
<Input.TextArea
259
rows={10}
260
style={{ fontFamily: "monospace", fontSize: "90%" }}
261
/>
262
</Form.Item>
263
<Form.Item
264
label="URL"
265
name="url"
266
rules={[{ required: false, type: "url" }]}
267
extra={`optional, external URL, will be shown as "Read more" link.`}
268
>
269
<Input allowClear />
270
</Form.Item>
271
<Form.Item label="Hide" name="hide" valuePropName="checked">
272
<Checkbox>If checked, will not be shown publicly.</Checkbox>
273
</Form.Item>
274
</Form>
275
<Divider />
276
<Row gutter={30}>
277
<Col span={16}>
278
<Paragraph>
279
<News news={{ ...data, id, date: data.date.unix() }} />
280
</Paragraph>
281
</Col>
282
<Col span={8}>
283
<Space direction="horizontal" size="large">
284
<Button
285
onClick={save}
286
disabled={saving || saved != null || invalid}
287
type="primary"
288
>
289
{isNew ? "Create" : "Save"}
290
</Button>
291
<Button href={slugURL({ ...data, id })}>Cancel</Button>
292
</Space>
293
<Divider type="horizontal" />
294
{error && <Alert type="error" message={error} />}
295
{saving && <Loading />}
296
{renderSaved()}
297
</Col>
298
</Row>
299
</>
300
);
301
}
302
303
function content() {
304
if (profile == null) return <Loading />;
305
if (!isAdmin) {
306
return <Alert type="error" message="Not authorized" />;
307
}
308
return edit();
309
}
310
311
const title = `${siteName} / Edit News / ${isNew ? "new" : `${id}`}`;
312
313
const items = [
314
{ key: "/", title: <A href="/">{siteName}</A> },
315
{ key: "/news", title: <A href="/news">News</A> },
316
{ key: "new", title: isNew ? "Create New" : `Edit #${id}` },
317
];
318
319
return (
320
<Customize value={customize}>
321
<Head title={title} />
322
<Layout>
323
<Header />
324
<Layout.Content
325
style={{
326
backgroundColor: "white",
327
}}
328
>
329
<div
330
style={{
331
minHeight: "75vh",
332
maxWidth: MAX_WIDTH,
333
padding: "30px 15px",
334
margin: "0 auto",
335
}}
336
>
337
<Breadcrumb style={{ margin: "30px 0" }} items={items} />
338
{content()}
339
</div>
340
<Footer />
341
</Layout.Content>
342
</Layout>
343
</Customize>
344
);
345
}
346
347
export async function getServerSideProps(context: GetServerSidePropsContext) {
348
const { query } = context;
349
const { id: idQ } = query;
350
351
if (idQ === "new") {
352
return await withCustomize({ context, props: { news: null } });
353
}
354
355
const id = extractID(idQ);
356
if (id != null) {
357
try {
358
// false: bypasses cache
359
const news = await getNewsItem(id, false);
360
if (news != null) {
361
return await withCustomize({ context, props: { news } });
362
}
363
} catch (err) {
364
console.log("Error loading news item", err.message);
365
}
366
}
367
368
return NOT_FOUND;
369
}
370
371